Browser : 멀티프로세서 브라우저의 렌더링
Chrome 멀티프로세스 아키텍처
크롬 브라우저는 멀티프로세스 구조로 설계되었다.
Chrome 아키텍처를의 최신 아키텍처
크롬 브라우저 프로그램은 멀티 프로세스의 구조로 설계되었고 아래와 같은 Process 종류가 있다.

- Browser Process : Chrome의 주소표시줄 같은 브라우저 UI부분과, 네트워크 요청 및 파일 엑세스를 담당
- Plugin Process : 웹사이트 플러그인 담당
- GPU Process : GPU 작업 담당
- Renderer Process : 웹사이트 렌더링을 담당하며 탭마다 따로 생성된다.
왜 Chrome은 멀티스레드가 아닌 멀티프로세스 아키텍처를 도입했을까 ?
프로세스는 독립적인 메모리 공간을 가지며, 다른 프로세스와 자원을 공유하지 않는다. 멀티 프로세스는 독립된 메모리 공간을 가진 프로세스들이 동시에 실행되는 방식으로 같은 데이터를 여러 프로세스에서 사용할 경우 중복 저장되어 메모리 사용량이 증가하고 프로세스 간 통신(IPC)을 위한 추가적인 오버헤드가 발생한다.
이런 단점에도 불구하고
웹 사이트의 경우 개발자가 별도 심사 없이 배포하기 때문에 악성 웹사이트일 수 있다. 만약 탭 하나하나가 스레드일 경우, 악성 웹사이트가 프로세스를 장악하면 브라우저에 담긴 모든 정보를 빼내올 수도 있고, 크롬 프로세스 자체가 죽어버리는 문제가 발생한다.
멀티 프로세스는 각 프로세스가 독립된 메모리 공간을 가지기 때문에, 하나의 프로세스가 실패해도 다른 프로세스에 영향을 주지 않는다. 탭마다 Renderer Process를 가지고 있는데, 한 탭이 죽거나 공격을 당해도 다른 탭이 사용하는 메모리가 죽거나 다른 탭이 먹통이 되는 일을 막을 수 있다. 멀티 프로세스를 채택하면, 프로세스가 병렬적으로 작업하기 때문에 웹 페이지 로딩 속도와 응답 속도를 높일 수 있다.
프로세스와 쓰레드의 차이가 무엇인가
프로세스는 실행 중인 프로그램의 인스턴스, 프로세스는 독립적인 메모리 공간을 가지며, 다른 프로세스와 자원을 공유하지 않는다. 이는 프로세스 간의 데이터 공유를 어렵게 만들지만, 안정성을 높인다. 멀티 프로세스는 독립된 메모리 공간을 가진 프로세스들이 동시에 실행되는 방식입니다.
스레드는 프로세스 내에서 실행되는 실행 단위로, 하나의 프로세스는 여러 스레드를 가질 수 있으며, 이 스레드들은 프로세스의 자원을 공유한다. 멀티 스레드의 장점은 메모리 공유로 인한 효율성이다. 스레드 간 데이터를 공유할 수 있으므로, 데이터 복사 비용이 줄어들고, 통신 비용이 낮아진다. 하지만, 공유된 메모리에 여러 스레드가 동시에 접근할 경우를 대비하여 임계영역에 대한 방어처리를 해야한다.
IPC (Inter Process Communication)
- Shared Memory : 공유하는 메모리 주소를 할당한다. 프로세스는 shared memory에 바로 접근
- Pipe : 반이중통신임 Read/Write 확정적,
- Socket
- Message Queue
- Remote Procedure Call (RPC)
멀티스레드와 멀티프로세스 임계영역에 대한 방어 ⇒ Mutex와 Semaphore
- Semaphore : 허용 가능한 접근 개수(count) 를 지정하는 신호등 같은 제어 장치
- Mutex : 오직 한 스레드만 임계 구역(공유 자원)에 들어갈 수 있게 하는 잠금
- DeadLock(교착상태) : 세마포어와 뮤텍스 잘못 쓰면, 프로세스끼리 무한 대기하는 교착상태 발생
그렇다면 어떻게 웹사이트가 렌더링 되는 것일까
브라우저에 URL을 입력하면 그 주소에 있는 서버가 HTML을 반환하고 CSS와 Javascript를 불러와서 렌더링을 하게 된다. 이 과정을 어떤 Chrome Process가 진행하는지, 어떻게 진행되는지 자세하게 알아보자.
( 물론 다른 형태를 넘겨줄 수도 있다. JSON, XML 또 뭐가 있을까 )
1. 요청 단계
Browser Process가 메인이 된다. Browser Process에는 여러 종류의 Thread가 존재한다.
- 브라우저 UI를 담당하는
UI Thread - 인터넷에서 데이터를 송수신하기 위한
Network Thread - 파일 접근을 위한
Storage Thread가있다.
이제 시나리오를 통해 더 자세히 알아보자
- 사용자가 URL에 입력을 시작하면
UI Thread가 URL인지 검색어인지 구분 - 사용자가 엔터를 누르면 검색어라면 검색엔진으로 입력을 전송하고, URL이라면
UI Thread가 네트워크 호출을 시작(실제 외부로 나가는 것이 아니라 작업의 트리거 역할) - 네트워크 요청이 시작되면
Network Thread는 DNS 조회로 IP를 가져오거나 TLS 연결 같은 프로토콜을 따른다. - 응답 본문이 수신되기 시작하면
Network Thread는Content-Type헤더를 읽고 응답을 어떻게 처리할지 판단- Content-Type이 없는 경우 MIME 스니핑을 통해 추론하기도 하며
- 응답이 파일이라면 다운로드 관리자? 에게 응답을 전달하고, 응답이 HTML이라면 최종적으로
Renderer Process에게 전달 - 여기서
Cross-Origin-Read-Blocking(오리진이 다른 HTML을 읽지 못하도록)이 실행된다.
- 안전한지, HTML인지 확인하는 작업이 완료되면
Nextwork Thread가UI Thread에게 작업 완료를 알린다. - UI Thread는
IPC를 통해Renderer Process에게 HTML을 전달한다.- 이미 UI Thread가 입력을 받을 때, 어떤 Renderer Process가 응답을 받아야하는지 알고 있었다.
- 그리고 브라우저 앞으로/뒤로가기 버튼이 업데이트되고 주소표시줄이 새로운 URL로 옮겨지게 된다.
만약 서비스워커가 있다면 어떨까 ?
서비스 워커는 Rendering Process에서 실행되는 JavaScript 코드면서 서비스워커는 네트워크 프록시로,
서비스 워커가 캐시에서 페이지를 로드하도록 설정된 경우 네트워크에서 데이터를 요청할 필요가 없습니다.
서비스워커는 생성시 Network Thread가 참조하고 있는 덕분에
네트워크 요청이 발생하면, 도메인에 연결된 서비스워커를 찾은후 서비스워커가 실행되면서 네트워크 요청을 할 것인지 캐시를 쓸 것인지 제어하게 된다.
2. 렌더링 단계
Rendering Process가 메인이 된다. 렌더링을 담당하는 Renderer Process에도 여러 종류의 Thread가 존재한다.
- DOM 파싱, JS 실행을 담당하는
Main Thread - 서비스 워커를 실행하는
Worker Thread - 합성 단계를 실행하는
Compositor Thread - 래스터화 단계를 실행하는
Raster Thread
요청 단계에서 렌더러 프로세스에게 응답을 내려주면, 아래와 같이 동작하게 된다.
2-1. HTML 파싱하며 DOM Tree 생성
HTML을 수신하기 시작하면 Main Thread가 HTML Parser를 실행해
응답으로온 바이트스트림 -> Token -> AST -> DOM으로 파싱하기 시작한다.
-
CSS, Image 로드
동시에
로드스캐너는 도큐먼트 문자열을 훑고<img>,<link>항목을 발견하면, HTML파서에서 생성된 Token을 보고IPC를 통해Browser Process의 Nextwork Thread에게 JS와 CSS 로드를 요청한다. 즉, DOM생성까지 기다리지 않고 미리 CSS와 Image를 로드하는 것이다.CSS를 로드하면 브라우저는 CSS를 파싱하여
CSSOM을 만든다. 이 작업은 비동기로 또 병렬로 실행된다. -
Javascript 로드.파싱.실행
파싱을하다가
<script>태그를 만나면, HTML 문서의 파싱을 일시중지하고 Javascript를 로드/파싱/실행까지 한다. 왜냐하면, JS는 DOM을 변경하는 코드들이 포함될 수 있기 때문에, 동기적으로 로드하고 실행까지 먼저 하는 것이다.다만,
<script>에async혹은defer를 어트리뷰트를 넣으면, 브라우저는 이것을 DOM 조작이 없다는 힌트를 줘서 비동기적으로 로드하고 실행(defer는 파싱끝나고)하여 파싱을 차단하지 않는다 그렇기 때문에 async나 defer 키워드 없이 script 태그를 사용한다면 body의 가장 아래에 위치하는 것이 UX에 좋다.
<link>의 적절한 위치깜빡임을 방지하기 위해 최대한 빨리 불러오는 것이 좋다. 그래서
<head>태그의 상단에 위치하는 것이 좋다.
2-2. DOM + CSSOM = Render Tree
DOM과 CSSOM이 모두 만들어지면 렌더트리를 만든다. 여기서 알아야할 점은, 위 단계에서 CSS를 로드하면 CSSOM을 만든다고 했는데 이 CSSOM이 다 만들어질때까지 Render Tree 생성이 지연된다.
Main Thread가 DOM Tree를 순회하면서 맞는 CSS규칙을 찾아가며 LayoutObject(RenderObject)를 만들며 Render Tree를 만든다. 이때 렌더링 트리에는 페이지를 렌더링하는데 필요한 노드들만 포함된다. 즉<meta/>태그나display:none과 같은 스타일을 가진 노드는 포함되지 않는다.
Render Tree에는 어떤 요소가 어떤 스타일이 적용될지 맵핑해둔 트리라고 생각하면 된다.
Render Blocking Resource
Javascript 로드시에 메인쓰레드가 블락되어 실행이 멈추는 것처럼
CSS도 로드와 파싱은 비동기지만, Render Tree를 만들 때 CSSOM이 완성되어야한다는 점에서
CSS와 JS 모두 Render Blocking Resource다.
3. Layout/Reflow 단계
Render Tree의 각 노드에는, 아직 노드마다 각자의 스타일만 존재한다.
그렇기 때문에, 상대적으로 어디에 위치해야 하는지, 혹은 크기가 얼마나 되는지 모른다.
요소의 크기나 위치 등이 계산되어 Render Tree에 적용하게 되는데,
크롬에서는 이렇게 layout 작업이 완료된 트리를 Layout Tree라고도 부른다
또, DOM조작이나 리렌더링이 발생하면 레이아웃단계 부터 다시 시작하는데,
이럴 때는 Layout 단계를 Reflow 단계라고도 칭한다.
- Main Thread는
Render Tree의 루트부터 순회하며, 각 위치나 요소들의 크기를 결정한다. 이때추상클래스가 있다면, 추상클래스만을 위한 노드가 따로 생성되지는 않지만, 연결된 노드에 추상클래스 관련 데이터를 업데이트한다.
4. Paint/RePaint 단계
DOM, 스타일, 레이아웃이 있어도 페이지를 렌더링하기에 충분하지 않다.
요소의 크기, 모양, 위치를 알고 있지만 어떤 순서로 페인트할지는 판단해야 한다.
예를 들어 특정 요소에 z-index가 설정될 수 있는데, 이 경우 HTML에 작성된 요소의 순서대로 페인팅하면 잘못된 렌더링이 발생할 수 있다.
Main Thread는 그릴 순서를 확정하기 위해Layout Tree를 순회하며Paint Record라는 것을 만든다.
Paint Record는, 어디에 선을 그어라, 저기서 텍스트를 채워라 같은 그리기 명령어 목록이다.
리렌더링이 발생해서 다시 Paint하는 작업을 RePaint라고 부른다.
5. Layerize 단계
브라우저는 Layout Tree를 여러 그룹(Layer)로 나눠 다룬다.
Main Thread가 Layout Tree를 순회하면서Layer Tree를 생성한다.- 이때 보통 새로운 Staicking Context를 만드는 스타일(
transform,opacity,postion:fixed)이 포함되는 요소가 있다면, 특별한 Layer인합성 레이어로 다루게 된다. - 합성레이어는 리렌더링 시기에, Reflow나 RePaint 단계를 타지 않고 GPU를 통해 직접 계산된다. 그래서 애니메이션 최적화에 유리하다.
- 이때 보통 새로운 Staicking Context를 만드는 스타일(
6. Rasterize/Composite 단계
Raster Thread가 Layer를 픽셀로 변환한다.- 이 비트맵을 GPU 메모리에 저장
Compositor Thread가 GPU에게 명령을 내려 화면 출력을 요청한다.- 작업이 모두 완료되면,
Compositor Thread는 IPC를 통해Browser Process로 완료되었음을 알리고 브라우저 상단의 로딩 스피너가 빠지게된다.
- 작업이 모두 완료되면,
애니메이션 최적화 기법
1. Frame마다 렌더링파이프를 업데이트하려면 ? => requestAnimationFrame()
렌더링 파이프는 2단계를 거치면 필연적으로 3,4,5 단계가 순차적으로 실행된다. 그래서 요소의 위치가 바뀌면, Reflow -> RePaint 작업까지 다시 진행하게 된다. 그 작업이 애니메이션이라면 매 프레임마다(보통 60fps 혹은 16ms) 이런 비싼 작업을 해야된다. 이 비싼 작업동안 한 프레임이라도 놓치게 되면 애니메이션이 버벅거리게 된다.
또, 이런 렌더링 작업은 Main Thread에서 이뤄진다. 그리고 Javascript도 Main Thread 안에서 v8 엔진이 수행되는 것이다.
그래서 애니메이션 도중에 다른 Javascript까지 실행된다면 프레임마다 수행되는 렌더링파이프라인도 멈출 수 있다.
setInterval을 사용하더라도 똑같다. 시간은 고려하지만, 콜스택이 하나뿐인 싱글쓰레드 언어라 실제 그 시간에 정확히 실행되진 못한다.
이렇게 프레임별로 애니메이션을 보장해주는 친구가 requestAnimationFrame이다.
requestAnimationFrame의 콜백함수는 다른 TaskQueue인 rAF Queue에 담긴다.
이 rAF Queue는 이벤트루프에 의해, 매 렌더링마다 Paint 직전에 꼭 수행되는 것을 브라우저가 보장한다.
그래서 주사율에 맞춰 꼭 실행되므로 끊김없는 렌더링이 이뤄진다.
2. transfrom opacity 애니메이션이 성능이 좋은 이유
transform과 opacity는 새로운 stacking context를 만든다. 이런 요소로 stacking context가 만들어지면 Layerize에서 특별한 레이어(합성 레이어)로 취급된다.
이 특별한 레이어는 리렌더링될때, transform과 opacity 값의 변경이 필요하면 Reflow와 Repaint 단계를 진행하지 않고 바로 Composite 단계로 넘어가 Compositor Thread가 GPU 프로세스에게 명령을 내려 위치/투명도를 변경하는 작업을 한다.
즉 Main Thread와 별도로 진행되므로 더 자연스러운 애니메이션이 된다.
다만, transform이나 opacity가 설정된 요소라도, 레이아웃(Layout)이나 페인트(Paint)에 영향을 주는 속성이 변경되면 메인 스레드가 다시 작업을 수행한다.
References
https://developer.chrome.com/blog/inside-browser-part1 https://developer.chrome.com/blog/inside-browser-part2 https://developer.chrome.com/blog/inside-browser-part3 https://web.dev/articles/critical-rendering-path/render-tree-construction?hl=ko http://web.dev/articles/animations-guide?hl=ko